EKS Auto Mode で優先度の低い Pod を配置して、Pod が無くても常に一定数のノードが起動するようにする

EKS Auto Mode で優先度の低い Pod を配置して、Pod が無くても常に一定数のノードが起動するようにする

EKS Auto Mode には下記メリットがあり、採用することでユーザー側の管理対象を大きく減らすことができます。

  • ノード管理(スケーリング、パッチ管理)を AWS に移譲できる
    • スケーリングはマネージドな形でインストールされた Karpenter が行う
  • 必須コンポーネントが AWS 管理となっている
    • VPC CNI
    • CoreDNS
    • AWS LoadBalancer Controller
    • EBS CSI driver
    • Pod Identity Agent
    • ノード監視エージェント

一方で Karpenter 前提の仕組みとなるため、ノードのスケールアウト/スケールインが積極的に実行されることを意識して利用する必要があります。
特に Pod が存在しない Node はすぐスケールインされるので、細かい Job が継続的に発生するようなワークロードでは過剰にスケールインされる可能性があります。
Job 起動の度に EC2 を立ち上げる挙動になって、その分のオーバーヘッドが毎回発生する形になるわけです。
この辺りは Karpenter 入り EKS と GitLab Runner の組み合わせで利用する際の注意点として、スリーシェイクさんや Sky さんがわかりやすい資料を公開して下さっています。

https://speakerdeck.com/toshikish/autoscaling-gitlab-ci-cd-with-karpenter

https://www.skygroup.jp/tech-blog/article/547/

この手法では、余剰な Pod をプロビジョニングしておくことで必要な Node 数を確保します。
余剰 Pod は優先度を低く設定しておくことで、必要な Pod がデプロイされたら追い出されるように設計できます。
こうすることで、Job 実行時に毎回 Node プロビジョニングが発生する状況を避けることができるわけです。
Cluster Autoscaler の場合ですが、EKS Workshop でも同様の手法が紹介されています。

https://www.eksworkshop.com/docs/autoscaling/compute/cluster-autoscaler/overprovisioning/setting-up

GKE AutoPilot でも同様の課題があり、下記記事からこの手法で利用される余剰 Pod は Balloon Pod と呼ばれたりします。

https://wdenniss.com/gke-autopilot-spare-capacity

管理対象を減らす目的で EKS Auto Mode を採用したものの、この辺りの挙動に苦しむ方がいるかもしれないと思ったので、今回 EKS Auto Mode で検証してみます。
ちなみに、この方法は現時点では良いだろうというだけなので、Karpenter の機能追加によってはより良い解決方法が生まれるかもしれません。
下記 issue も継続的して確認しておくと良いと思います。

https://github.com/kubernetes-sigs/karpenter/issues/749

やってみる

Auto Mode を有効化した EKS v1.31 を用意します。
この状態では Node が存在しません。

% kubectl get node
No resources found

ここで Job を実行してみます。

apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  template:
    spec:
      containers:
        - name: pi
          image: perl:5.34.0
          command: ["perl", "-Mbignum=bpi", "-wle", "print bpi(2000)"]
          resources:
            requests:
              cpu: 500m
              memory: 1000Mi
      restartPolicy: Never
  backoffLimit: 4

上記マニフェストファイルは Kubernetes 公式ドキュメントから引っ張ってきた、π を 2000 桁まで計算して出力する Job に requests の指定を追加しています。

https://kubernetes.io/ja/docs/concepts/workloads/controllers/job/

Job 自体は 10 秒で終了する簡単なものですが、Node のプロビジョニングで 50 秒ほど要しています。

% kubectl describe pod
Name:             pi-89m47
Namespace:        default
Priority:         0
Service Account:  default
Node:             i-0740e2a4362e88675/10.0.101.77
Start Time:       Mon, 06 Jan 2025 16:51:22 +0900
Labels:           batch.kubernetes.io/controller-uid=3bb71e18-f3e6-4220-9851-f5334717d5a7
                  batch.kubernetes.io/job-name=pi
                  controller-uid=3bb71e18-f3e6-4220-9851-f5334717d5a7
                  job-name=pi
Annotations:      <none>
Status:           Succeeded
IP:               10.0.101.80
IPs:
  IP:           10.0.101.80
Controlled By:  Job/pi
Containers:
  pi:
    Container ID:  containerd://62b527d712eb7210c13232af760f0532b6b925014d117008eec03ef68098bdfd
    Image:         perl:5.34.0
    Image ID:      docker.io/library/perl@sha256:2584f46a92d1042b25320131219e5832c5b3e75086dfaaff33e4fda7a9f47d99
    Port:          <none>
    Host Port:     <none>
    Command:
      perl
      -Mbignum=bpi
      -wle
      print bpi(2000)
    State:          Terminated
      Reason:       Completed
      Exit Code:    0
      Started:      Mon, 06 Jan 2025 16:51:50 +0900
      Finished:     Mon, 06 Jan 2025 16:51:54 +0900
    Ready:          False
    Restart Count:  0
    Requests:
      cpu:        500m
      memory:     1000Mi
    Environment:  <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-nl2k6 (ro)
Conditions:
  Type                        Status
  PodReadyToStartContainers   False
  Initialized                 True
  Ready                       False
  ContainersReady             False
  PodScheduled                True
Volumes:
  kube-api-access-nl2k6:
    Type:                    Projected (a volume that contains injected data from multiple sources)
    TokenExpirationSeconds:  3607
    ConfigMapName:           kube-root-ca.crt
    ConfigMapOptional:       <nil>
    DownwardAPI:             true
QoS Class:                   Burstable
Node-Selectors:              <none>
Tolerations:                 node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                             node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
  Type     Reason                  Age                 From               Message
  ----     ------                  ----                ----               -------
  Normal   Nominated               103s                karpenter          Pod should schedule on: nodeclaim/general-purpose-2bsv6
  Warning  FailedScheduling        88s (x5 over 104s)  default-scheduler  no nodes available to schedule pods
  Normal   Scheduled               78s                 default-scheduler  Successfully assigned default/pi-89m47 to i-0740e2a4362e88675
  Warning  FailedCreatePodSandBox  78s                 kubelet            Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "34c5f07273d27bc03be1e4ff6391a855dcf6237eea5a75dd032a5a4b5764bda9": plugin type="aws-cni" name="aws-cni" failed (add): add cmd: Error received from AddNetwork gRPC call: rpc error: code = Unavailable desc = connection error: desc = "transport: Error while dialing: dial tcp 127.0.0.1:50051: connect: connection refused"
  Normal   Pulling                 65s                 kubelet            Pulling image "perl:5.34.0"
  Normal   Pulled                  51s                 kubelet            Successfully pulled image "perl:5.34.0" in 13.9s (13.9s including waiting). Image size: 336374010 bytes.
  Normal   Created                 51s                 kubelet            Created container pi
  Normal   Started                 51s                 kubelet            Started container pi

これで問題無い場合も多いと思いますが、起動時間がシビアなケースもあるかと思います(例えば GitLab の CI であれば、push 後に CI 結果を確認できるまでの時間が開発体験に直結します)。

それでは、余剰に Pod をプロビジョニングする手法を試してみます。
まずは Default の PriorityClass を作成します。

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: default
value: 0
preemptionPolicy: PreemptLowerPriority
globalDefault: true
description: "Default Priority class."

Kubernetes には Pod をスケジューリングできない時に既存 Pod を追い出す(プリエンプション)機能があります。
preemptionPolicyPreemptLowerPriority になっている場合、自分より優先度の低い Pod を追い出してデプロイすることができます。

preemptionPolicy はデフォルトでは PreemptLowerPriority に設定されており、これが設定されている Pod は優先度の低い Pod をプリエンプトすることを許容します。これは既存のデフォルトの挙動です。 preemptionPolicy を Never に設定すると、これが設定された Pod はプリエンプトを行わないようになります。
https://kubernetes.io/ja/docs/concepts/scheduling-eviction/pod-priority-preemption/

次に余剰 Pod の PriorityClass を作成します。
こちらは、優先度を下げつつ、他 Pod を追い出さないように preemptionPolicyNever に設定します。

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: balloon
value: -10
preemptionPolicy: Never
globalDefault: false
description: "Balloon Pod priority."

PriorityClass 一覧を取得すると下記のようになります。

% kubectl get priorityclass
NAME                      VALUE        GLOBAL-DEFAULT   AGE
balloon                   -10          false            11s
default                   0            true             18s
system-cluster-critical   2000000000   false            149m
system-node-critical      2000001000   false            149m

PriorityClass balloon を指定して、Pod を作成します。
起動さえしていればどんなコンテナでも良いので、無限に sleep させておきます。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: balloon
spec:
  replicas: 3
  selector:
    matchLabels:
      pod: balloon
  template:
    metadata:
      labels:
        pod: balloon
    spec:
      priorityClassName: balloon
      terminationGracePeriodSeconds: 0
      containers:
        - name: ubuntu
          image: ubuntu
          command: ["sleep"]
          args: ["infinity"]
          resources:
            requests:
              cpu: 500m
              memory: 750Mi

この状態では c5a.large のノードが一つ起動され、ほぼ利用されている状態です。

スクリーンショット 2025-01-06 17.03.13.png

先ほどと同様の Job を実行してみます。
利用可能な CPU のキャパシティが 500m も無いため、プリエンプションしなければ既存ノードにはデプロイできないはずです。
ただ、既存のインスタンス(i-059fc36710c53b69c)にスケジュールされて、2 秒程度でイメージの Pull が始まっています。

% kubectl describe pod pi-j5z96
Name:                 pi-j5z96
Namespace:            default
Priority:             0
Priority Class Name:  default
Service Account:      default
Node:                 i-059fc36710c53b69c/10.0.100.201
Start Time:           Mon, 06 Jan 2025 17:09:01 +0900
Labels:               batch.kubernetes.io/controller-uid=93c079d1-1a45-47ae-adf0-5346122d3983
                      batch.kubernetes.io/job-name=pi
                      controller-uid=93c079d1-1a45-47ae-adf0-5346122d3983
                      job-name=pi
Annotations:          <none>
Status:               Running
IP:                   10.0.100.51
IPs:
  IP:           10.0.100.51
Controlled By:  Job/pi
Containers:
  pi:
    Container ID:  containerd://abc0c6a63d5c11d518018025c56fb14b245543cb0ce80d16f74a1814a7ff3a92
    Image:         perl:5.34.0
    Image ID:      docker.io/library/perl@sha256:2584f46a92d1042b25320131219e5832c5b3e75086dfaaff33e4fda7a9f47d99
    Port:          <none>
    Host Port:     <none>
    Command:
      perl
      -Mbignum=bpi
      -wle
      print bpi(2000)
    State:          Running
      Started:      Mon, 06 Jan 2025 17:09:16 +0900
    Ready:          True
    Restart Count:  0
    Requests:
      cpu:        500m
      memory:     1000Mi
    Environment:  <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-n58kw (ro)
Conditions:
  Type                        Status
  PodReadyToStartContainers   True
  Initialized                 True
  Ready                       True
  ContainersReady             True
  PodScheduled                True
Volumes:
  kube-api-access-n58kw:
    Type:                    Projected (a volume that contains injected data from multiple sources)
    TokenExpirationSeconds:  3607
    ConfigMapName:           kube-root-ca.crt
    ConfigMapOptional:       <nil>
    DownwardAPI:             true
QoS Class:                   Burstable
Node-Selectors:              <none>
Tolerations:                 node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                             node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  19s   default-scheduler  0/1 nodes are available: 1 Insufficient cpu, 1 Insufficient memory.
  Normal   Scheduled         17s   default-scheduler  Successfully assigned default/pi-j5z96 to i-059fc36710c53b69c
  Normal   Pulling           17s   kubelet            Pulling image "perl:5.34.0"
  Normal   Pulled            2s    kubelet            Successfully pulled image "perl:5.34.0" in 14.383s (14.383s including waiting). Image size: 336374010 bytes.
  Normal   Created           2s    kubelet            Created container pi
  Normal   Started           2s    kubelet            Started container pi

無事、EKS Auto Mode でも Balloon Pod のメリットを享受できていますね!
こちらも Sky さんの記事 で既に書かれている内容ですが、スケジュールベースで 0 スケール可能な KEDA と組み合わせれば、日中帯のみ Node をオーバープロビジョニングするようなことも可能です。
例えば下記のような定義を行うことで、8:30 に Node を起動して、18:00 過ぎに Node を削除するようなことが可能です。

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: balloon
spec:
  cooldownPeriod: 300
  minReplicaCount: 0
  scaleTargetRef:
    name: balloon
  triggers:
    - metadata:
        desiredReplicas: "3"
        start: 30 8 * * *
        end: 00 18 * * *
        timezone: Asia/Tokyo
      type: cron

※ あくまで事前に用意する Node の話なので、Pod が起動されれば夜間帯でも Node が立ち上がります。

KEDA は Helm で簡単にインストールできるので是非試してみて下さい。

https://keda.sh/docs/2.16/deploy/

まとめ

EKS Auto Mode で優先度の低い Pod を作成して、常時 Node が起動されるようにしてみました。
GitLab Runnner のみを EKS で動かす時のような、常時細かい Job が作成され続ける環境では有用なテクニックでは無いでしょうか。
この記事がどなたかの参考になれば幸いです。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.